Skip to content

chore(lessons): promote both pending lessons, one as a gate and one as a rule - #597

Merged
thomasluizon merged 6 commits into
mainfrom
chore/promote-pending-lessons
Jul 24, 2026
Merged

thomasluizon merged 6 commits into
mainfrom
chore/promote-pending-lessons

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Jul 24, 2026 •

Copy link
Copy Markdown
Owner

Clears .claude/pending-lessons.md before Phase 7. Both queued entries were real failures that had never been acted on; each is routed to the tier its type calls for.

Lesson 1 (checkable) becomes a gate, not prose

The race is real, and the current code does not handle it. gh pr update-branch rewrites the head SHA and re-triggers the review check, but reviewDecision is a PR-level field that survives the push, so it keeps reporting the pre-update APPROVED for as long as the re-review runs. Since review is not a required check, mergeStateStatus can reach CLEAN with Build/Tests/Sonar green while the Claude review is still executing. The sweeps re-read reviewDecision every poll, but every read in that window returns the stale APPROVED, so they merge. That is how a HIGH backend-contract finding reached main and deployed on orbit-api#403, with the fix landing on the orphaned head branch.

Both sweeps now:

  • resolve once per run, fail-closed, whether the repo ships .github/workflows/claude-review.yml, and unless it positively does not, block every merge path (including the coverage-only admin override) until the review check on the current head SHA is terminal, then re-read reviewDecision. statusCheckRollup is scoped to the head commit, so a run from the pre-update SHA cannot satisfy the wait. A repo that positively has no such workflow (orbit-landing-page) never waits for a check that will not run;
  • record the head SHA that was actually merged for each PR and re-check the branch at the end of the sweep. A surviving branch whose tip has moved past that SHA carries a post-merge commit that never reached main: printed as ORPHANED-HEAD, exit 1. A branch that merely survived --delete-branch with an unchanged tip is benign and is not reported;
  • gain --help, documented exit codes (0 clean / 1 orphaned head / 2 bad usage) per tools/CONVENTIONS.md, and a timeout line that names the actual blocker instead of a generic "waiting for CLEAN".

Why the precondition check itself fails closed

The first draft of this guard computed review_required from an unpaginated, error-swallowed gh api repos/<repo>/actions/workflows call and defaulted to OFF. gh api does not auto-paginate and that endpoint defaults to per_page=30, so claude-review.yml could sit on page 2 in a repo with more than 30 workflows; separately, 2>/dev/null hid every auth, rate-limit and network failure. Either condition silently disabled the whole guard and restored the exact merge behaviour this PR exists to prevent, with no error line.

It now uses --paginate and inverts the default: required unless a successful lookup positively shows the workflow is absent, with a stderr warning when the lookup fails. That is the locked "Fail-closed the completion gate: a verifier error is not a clean pass" decision (2026-07-18, re-confirmed 2026-07-23 after a gate-tamper hook was found failing open) applied one level up. A guard whose own precondition check fails open is the same defect, just moved.

merge-sweep.sh carried the same race in a stronger form (it merged the moment the state was decidable, without waiting for checks at all), so it is fixed in the same pass rather than left as a known-broken twin of the script being hardened.

Proof the guard fires

Run against a stub gh on PATH (stub + harness live in the scratchpad, never against real PRs). Case 1 feeds both the pre-change and post-change scripts the exact #403 shape: mergeStateStatus=CLEAN, reviewDecision=APPROVED, review check IN_PROGRESS on the head SHA.

###### 1. THE BUG: review re-triggered by update-branch is still RUNNING on the head SHA,
######    while the PR-level reviewDecision still reads the pre-update APPROVED.
CASE: old script (git show origin/main), same inputs
MERGED #403 (clean)
--- merge attempts: 1 ---
MERGE-ATTEMPT pr merge 403 --repo thomasluizon/orbit-api --squash --delete-branch

CASE: guarded script, same inputs
SKIP #403 (timeout: the review check on head deadbeef never settled (state=RUNNING), so the APPROVED is stale)
--- merge attempts: 0 ---

###### 2. The re-review settles to CHANGES_REQUESTED (what #403 actually was).
SKIP #403 review=CHANGES_REQUESTED
--- merge attempts: 0 ---

###### 3. Control: the re-review settles and re-confirms APPROVED, so the merge proceeds.
MERGED #403 (clean)
--- merge attempts: 1 ---

###### 4. A repo with no claude-review.yml never waits for a check that will not run.
MERGED #403 (clean)
--- merge attempts: 1 ---

###### 4b. REVIEW FINDING (High): a FAILED workflow lookup must keep the guard on, not skip it.
WARN: could not list thomasluizon/orbit-api workflows; assuming the review check is required
SKIP #403 (timeout: the review check on head deadbeef never settled (state=RUNNING), so the APPROVED is stale)
--- merge attempts: 0 ---

###### 5. Orphaned head: the branch tip MOVED past the merged SHA (deadbeef).
MERGED #403 (clean)
ORPHANED-HEAD #403 feature/orb-1-thing tip=cafe1234 (moved past the merged deadbeef, so those commits are NOT on main)
--- exit: 1

###### 5b. REVIEW FINDING (Medium): a branch that merely survived --delete-branch (tip still at the
######     merged SHA) is benign and must NOT be reported.
MERGED #403 (clean)
--- exit: 0

###### 5c. REVIEW FINDING 2 (Medium): a ref lookup that FAILS is UNKNOWN, not a clean pass.
MERGED #403 (clean)
WARN: could not verify branch feature/orb-1-thing for #403; orphan status unknown
--- exit: 3

###### 5d. A branch confirmed DELETED (empty oid, exit 0) is the normal case: silent, exit 0.
MERGED #403 (clean)
--- exit: 0

###### 6. The sibling merge-sweep.sh carries the same guards.
CASE: old merge-sweep.sh, review RUNNING       -> MERGED #403                          (merge attempts: 1)
CASE: guarded merge-sweep.sh, review RUNNING   -> SKIP #403 (timeout: ... stale)       (merge attempts: 0)
CASE: guarded merge-sweep.sh, lookup FAILS     -> WARN + SKIP #403 (timeout: ... stale) (merge attempts: 0)
CASE: guarded merge-sweep.sh, orphaned head    -> MERGED + ORPHANED-HEAD, exit 1

###### 7. --help and bad usage.
Usage: merge-sweep-cov.sh <owner/repo> <pr-number>...
no-args exit: 2

Review findings addressed

Finding Resolution
HIGH: guard silently disables itself on an unpaginated/failed workflow lookup Fixed. --paginate plus an inverted default (required unless positively absent), with a stderr warning on lookup failure. Proven by case 4b.
MEDIUM: orphan check false-positives on a delayed or failed branch delete Fixed. The merged head SHA is recorded and the surviving branch's tip is compared to it; only a moved tip is flagged. Proven by cases 5 and 5b.
MEDIUM (2nd review): orphan scan swallowed non-404 gh api failures as "no orphan" Fixed. The ref is resolved via GraphQL, which exits 0 with an EMPTY oid for a deleted branch, so a non-zero exit is unambiguously "could not verify": warn on stderr and exit 3, distinct from 1. Proven by cases 5c and 5d.

Lesson 2 (judgment) becomes standing guidance

Landed in .claude/skills/orchestrate/SKILL.md, under the existing Delegation discipline section, as "Waiting is foreground work, on both sides". That section is the one place in the repo carrying both a delegation contract template and a wait-on-CI loop (## 3. Babysit), so it is the only candidate home with a real consumer; .claude/rules/core.md was rejected because it is paid for on every turn of every session while this applies only when delegating.

Both halves land, because the lesson records that the subagent-side warning alone had already failed twice:

  • subagent side: poll in the foreground with 60 to 120s sleeps inside your own turn; a stopped agent receives no notifications, so a background waiter it armed can never wake it;
  • parent side: a completion notification reading "waiting" / "standing by" / "monitor armed" is a nudge trigger, not progress. Read the real state and send the agent back with it.

Housekeeping

  • Both entries moved to ## Graduated in .claude/pending-lessons.md with the date and where each landed; the queue is now empty.
  • tools/README.md catalog rows updated for the changed behaviour and the new exit codes.
  • tools/dash-baseline.json shrinks by 4 (the em dashes in the two scripts are gone).

Verification

$ node tools/check-frontmatter.mjs
frontmatter ok: 40 skill and agent files parse

$ node .claude/hooks/test-hooks.mjs
ORBIT HOOK PARITY OK

$ node tools/check-dashes.mjs --check-baseline            # exit 0
$ bash -n tools/merge-sweep.sh tools/merge-sweep-cov.sh   # both OK

…s a rule

Clears `.claude/pending-lessons.md` before Phase 7.

Lesson 1 (checkable) becomes a gate in the merge sweeps. `gh pr update-branch`
rewrites the head SHA and re-triggers the `review` check, but `reviewDecision`
is PR-level and keeps the pre-update APPROVED while that re-review runs, so both
sweeps could squash-merge on a snapshot that predated the head they were merging.
That is how a HIGH backend-contract finding reached main on orbit-api#403 and the
fix landed on the orphaned head branch instead. Both scripts now:

- discover whether the repo runs `.github/workflows/claude-review.yml`, and if it
  does, refuse every merge path until the `review` check on the CURRENT head SHA
  is terminal, then re-read reviewDecision;
- record each merged PR's head branch and re-check it at end of sweep, printing
  ORPHANED-HEAD and exiting 1 when a post-merge push re-created it;
- gain `--help`, documented exit codes (0/1/2), and a timeout line that names the
  actual blocker instead of a generic "waiting for CLEAN".

`merge-sweep.sh` carried the same race in a stronger form (it never waited for
checks at all), so it is fixed in the same pass rather than left as known-broken.

Lesson 2 (judgment) becomes standing guidance in the orchestrate skill's
delegation-discipline section, which is the one place in the repo that carries a
delegation contract template and a wait-on-CI loop. Both halves land: subagents
poll in the foreground inside their own turn, and the parent treats a "standing
by / monitor armed" completion as a nudge trigger, not as progress.
@vercel

vercel Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jul 24, 2026 9:11pm

Request Review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #597

Scope: PR #597 in thomasluizon/orbit-ui-mobile (chore/promote-pending-lessons → main)
Recommendation: NEEDS WORK

Summary

The PR graduates two pending lessons: tools/merge-sweep.sh and tools/merge-sweep-cov.sh gain a
review-staleness guard (blocking merge until the review check on the current head SHA settles,
per the orbit-api #403 incident) and an end-of-sweep orphaned-head-branch scan; --help/exit-code
support is added per tools/CONVENTIONS.md; the second lesson lands as prose in
.claude/skills/orchestrate/SKILL.md. .claude/pending-lessons.md and tools/README.md are
updated in step, and the dash baseline shrinks correctly (verified: zero em dashes remain in either
script). The mechanism is sound in the case the PR's own test transcript exercises, but the
review_required gate that turns the whole safety mechanism on is itself computed by an
unpaginated, error-swallowed gh api call that silently reverts to the pre-patch (unsafe) behavior
on failure — a correctness gap in the exact code path this PR exists to harden.

Findings

Critical

None.

High

[HIGH] review-staleness guard silently disables itself on an unpaginated/failed workflow lookup
· dimension: 1. Correctness (this is the PR's own stated guarantee failing silently)
· location: orbit-ui-mobile/tools/merge-sweep.sh:55 (identical logic at orbit-ui-mobile/tools/merge-sweep-cov.sh:62)
· issue: `review_required` — the single switch that turns the new review-staleness guard on — is
  set from `gh api "repos/$repo/actions/workflows" --jq '.workflows[].path' 2>/dev/null | grep -qx
  "$REVIEW_WORKFLOW_PATH"`. `gh api` does not auto-paginate (opt-in via `--paginate`), and GitHub's
  "List repository workflows" endpoint defaults to `per_page=30`; a repo with more than 30 workflow
  files can return `claude-review.yml` on page 2+, which this call never fetches. Separately,
  `2>/dev/null` swallows ANY failure of the call (auth, rate limit, transient network) with no
  fallback. In every one of these cases `review_required` stays empty (not an error, just silently
  unset) — verified end-to-end: it is read exactly once, at merge-sweep.sh:96 / merge-sweep-cov.sh:129,
  and when empty, `review_stale` is never set, so the loop merges as soon as `ms` is CLEAN/UNSTABLE
  and `rev=APPROVED` with zero wait for the current SHA's `review` check. That is exactly the
  pre-patch behavior that caused orbit-api #403 (a HIGH backend-contract finding merged and deployed
  before the re-review landed) — the one incident this PR exists to prevent.
· risk: Currently latent for orbit-ui-mobile (20 workflow files today, confirmed via `ls
  .github/workflows`, under the 30-item page default), but silent and undetectable when it does
  trip — no error line, no warning, just a merge that shouldn't have happened. It also reintroduces
  the original bug on ANY transient `gh api` failure today, regardless of workflow count, since
  fail-open (guard skipped) is the wrong default direction for a safety gate — it should fail closed
  (assume the guard is required) when the check itself can't be confirmed.
· fix: Add `--paginate` to the `gh api "repos/$repo/actions/workflows"` call (or `-F per_page=100`,
  since workflow counts realistically stay under 100) so the lookup can't silently truncate. Also
  invert the failure direction: default `review_required=1` and only clear it when the `gh api` call
  positively succeeds AND positively confirms the workflow file is absent, rather than defaulting to
  "not required" and only setting it on a confirmed hit. That makes an API hiccup fail toward extra
  waiting (safe, at worst a slower sweep) instead of toward skipping the guard (unsafe).
· reference: CLAUDE.md rule 8 (error handling at boundaries; a trust-boundary call like this needs a
  defined failure behavior, not a silent swallow); rubric dimension 1 (Correctness — the diff's own
  stated guarantee).

Medium

[MEDIUM] orphaned-head-branch check can false-positive on a delayed or failed branch delete
· dimension: 1. Correctness / 3. SOLID (missing edge case in new logic)
· location: orbit-ui-mobile/tools/merge-sweep.sh:119-128; orbit-ui-mobile/tools/merge-sweep-cov.sh:160-169
· issue: The end-of-sweep scan treats "branch exists" as proof the branch was "re-created after the
  merge, so its commits are NOT on main." It never compares the branch's current tip SHA to the SHA
  that was actually merged — it only checks existence via `gh api repos/$repo/branches/$branch`. A
  branch can still exist after a successful `--squash --delete-branch` merge for reasons that have
  nothing to do with a post-merge push: `--delete-branch`'s own delete step failing separately from
  the merge (branch protection on the head branch, an insufficient token scope, a transient error)
  and the deletion itself lagging behind the read. That race is sharpest for the single-PR
  invocation shown in the PR's own test transcript (`merge-sweep-cov.sh <repo> 403`), where the
  orphan check runs immediately after that PR's own merge with no other PRs' processing time to
  absorb the delay.
· risk: A benign "branch didn't get deleted" is reported as "ORPHANED-HEAD ... commits are NOT on
  main" (a factually wrong claim — the squash-merge to main already happened) and the script exits 1,
  which any caller (the nightly sweep, a human running it ad hoc) will read as "an incident
  happened," triggering unnecessary investigation or fix-forward action.
· fix: Record the merged PR's `headRefOid` (already available from `gate()`'s SHA field in
  merge-sweep-cov.sh; add it to `mstate()` in merge-sweep.sh) alongside the branch name, and at
  scan time compare the still-existing branch's current tip SHA to that recorded SHA. Only flag
  ORPHANED-HEAD when the tip SHA differs from (or is ahead of) the merged SHA — proving a new commit
  landed on the branch post-merge — not merely when the branch still exists.
· reference: rubric dimension 1 (Correctness — boundary condition: delete-branch failure vs.
  genuine recreation); tools/CONVENTIONS.md "Gate tools" (a verdict should be computed from hard
  evidence, not a proxy that a benign condition can also satisfy).

Low / Info

None posted (signal gate: style-only observations excluded).

Subagents

Agent Verdict
parity-checker N/A — no apps/web/** or apps/mobile/** file changed
i18n-syncer N/A — no user-facing strings or packages/shared/src/i18n/* changed
contract-aligner N/A — no orbit-api or packages/shared/src/types/*/endpoints.ts change
security-reviewer N/A — no orbit-api code changed
design-reviewer N/A — no apps/*/orbit-landing-page UI file changed

Validation

Check Result
Lint N/A — this review session's sandbox blocks executing repo scripts/shell commands beyond git/gh; not run
Type check N/A — same sandbox restriction
Tests N/A — same sandbox restriction
Build (api) N/A — diff does not touch orbit-api

Deferred — N/A dimensions & files not verdicted

  • DESIGN.md / AI-slop (#8): N/A — no apps/* UI files in the diff.
  • Parity (#9): N/A — no apps/web/** or apps/mobile/** files in the diff.
  • i18n (#10): N/A — no i18n JSON or user-facing string changes.
  • Contract drift + backward-compat (#11): N/A — no packages/shared/src/types/*, endpoints.ts,
    or orbit-api DTO changes.
  • Security (#12, backend categories): N/A — no orbit-api code changed. Frontend-security
    categories (XSS, auth-state leakage) also N/A — no frontend code in this diff.
  • Backend hard rules (#13): N/A — no orbit-api changed.
  • FEATURES.md parity (#14): N/A — no user-facing feature surface change (internal tooling only).
  • Validation (Phase 7): not executed — this review session's tool sandbox declined to run shell
    scripts/node/npm commands (repeated "requires approval" on every attempt beyond git/gh
    read commands); the PR's own verification block (node tools/check-frontmatter.mjs,
    node .claude/hooks/test-hooks.mjs, node tools/check-dashes.mjs --check-baseline, bash -n on
    both scripts) was not independently re-run. Manually verified instead: grepped both scripts for
    the em-dash character and confirmed zero remain, matching the dash-baseline.json deletions;
    manually traced both scripts' bash control flow line-by-line for syntax and logic soundness in
    lieu of bash -n.
  • All 6 changed files received a verdict; nothing was skipped for size or scope.

What's good

  • The core review-staleness mechanism (poll the CURRENT head SHA's review check to a terminal
    state before trusting reviewDecision, since reviewDecision is PR-level and survives an
    update-branch) correctly targets the actual root cause of orbit-api #403, and the fix is applied
    to both sibling scripts (merge-sweep.sh and merge-sweep-cov.sh) rather than leaving one as a
    known-broken twin, matching the PR body's own reasoning.
  • --help, documented exit codes (0/1/2), and the usage()-on-bad-args path bring both
    scripts up to tools/CONVENTIONS.md's contract, which they previously lacked.
  • The orphaned-head-branch scan (existence check aside — see Medium finding) is a reasonable
    secondary signal for detecting a past occurrence of the original bug class.
  • .claude/pending-lessons.md correctly clears the queue and records where each lesson landed with
    dates, and tools/README.md catalog rows were updated in the same PR to describe the new
    behavior — no stale documentation left behind.
  • dash-baseline.json shrinks by exactly the count of em dashes actually removed (verified by grep
    against both files: zero remain).

Recommendation

Fix the High finding before merge: make the review_required detection resilient to pagination and
API failure, and fail closed (assume the guard is needed) rather than fail open when the workflow
lookup can't be confirmed — this is the exact silent-failure shape the PR itself exists to close.
The Medium finding (SHA-based orphan detection instead of bare existence) is worth fixing in the
same pass since it touches the same block of new code, but would not block merge on its own.

Comment thread tools/merge-sweep.sh Outdated
Comment thread tools/merge-sweep-cov.sh Outdated
thomasluizon and others added 2 commits July 24, 2026 17:19
…ot existence

Addresses both review findings on #597.

HIGH: `review_required`, the single switch enabling the review-staleness guard,
was computed from an unpaginated, error-swallowed `gh api ... /actions/workflows`
call and defaulted to OFF. `gh api` does not auto-paginate (the endpoint defaults
to per_page=30), and `2>/dev/null` hid auth, rate-limit and network failures, so
either condition silently restored the exact pre-patch merge behaviour the guard
exists to prevent. Now `--paginate`, and the default inverts: start required, and
clear it only when the lookup SUCCEEDS and positively shows no claude-review.yml.
A failed lookup warns on stderr and keeps waiting. This is the same rule as the
locked "Fail-closed the completion gate: a verifier error is not a clean pass"
decision, applied one level up, to the guard's own precondition check.

MEDIUM: the orphaned-head scan treated "branch still exists" as proof of a
post-merge push, but `--delete-branch` can fail or lag independently of the merge,
which would report a benign leftover branch as an incident and exit 1. Both
scripts now record the head SHA that was actually merged and flag ORPHANED-HEAD
only when the surviving branch's tip has MOVED past it. `mstate()` in
merge-sweep.sh gained the headRefOid field it needed for this.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #597

Scope: PR #597 in orbit-ui-mobile — chore(lessons): promote both pending lessons, one as a gate and one as a rule
Recommendation: APPROVE

Summary

This PR hardens tools/merge-sweep.sh and tools/merge-sweep-cov.sh against the review-staleness race that let a HIGH backend-contract finding reach main on orbit-api#403 (reviewDecision is PR-level and survives an update-branch push, so a sweep could merge on a stale pre-update APPROVED while the re-triggered review check was still running on the new head SHA). Both scripts now block every merge path — including the coverage-only admin override — until the review check on the current head SHA settles, fail closed on an unreadable workflow list (paginated gh api ... --paginate, default-required unless the lookup positively proves the workflow absent), and scan merged PRs' surviving head branches for a tip that moved past the merged SHA (not mere existence, which would false-positive on a slow --delete-branch). It also gains --help, documented exit codes, and graduates two pending-lessons.md entries. Diff is scoped entirely to .claude/ and tools/; no apps/*, packages/shared, or orbit-api files are touched, so the platform-parity/i18n/contract/design/backend-security dimensions are all N/A by gate.

I read both scripts end to end and traced the guard's control flow against the race it targets: the review_required fail-closed default, the per-iteration re-fetch of reviewDecision alongside the review check's settle state (so a flip to CHANGES_REQUESTED is picked up in the same gh pr view payload that observes the check settling), the coverage-admin path sitting after the staleness gate in merge-sweep-cov.sh, and the SHA-based (not existence-based) orphan check. All of it holds up, including the two edge cases the PR's own proof output claims (case 4b: failed workflow lookup keeps the guard on; case 5b: a branch that merely survived --delete-branch with an unmoved tip is not reported). The two findings this PR's own body lists as already fixed (unpaginated/error-swallowed workflow lookup defaulting open; existence- vs SHA-based orphan check) are present and correctly implemented in the diff as pushed — nothing further to flag there.

Findings

Critical

None.

High

None.

Medium

[MEDIUM] merge-sweep.sh has no fast-path SKIP when reviewDecision is not APPROVED, unlike its sibling
· dimension: SOLID / clean architecture (rule 10, cross-script consistency) — pattern inconsistency
· location: tools/merge-sweep.sh:82-122
· issue: merge-sweep-cov.sh's loop (tools/merge-sweep-cov.sh:112-116) checks `[ "$rev" != "APPROVED" ]`
  first and SKIPs immediately with an explicit `review=$rev` message. merge-sweep.sh has no equivalent
  early exit: once the review check settles to CHANGES_REQUESTED, `review_stale` clears but the merge
  condition at line 107 still requires `rev = APPROVED`, so a non-approved PR just falls through to
  `sleep 20` and loops for up to the full ~50 iterations before printing a generic
  "SKIP #$n (timeout: never reached a mergeable state (ms=... rev=CHANGES_REQUESTED))".
· risk: not a correctness bug (it still correctly refuses to merge), but every rejected/changes-requested
  PR in a batch costs the sweep up to ~17 minutes of pointless polling instead of exiting immediately,
  and the eventual message reads as a generic timeout rather than the clear, fast `SKIP #$n review=$rev`
  its sibling script gives. This is exactly the kind of drift between the two scripts that
  `tools/README.md`'s "like merge-sweep.sh, but…" framing implies shouldn't exist.
· fix: add the same early check used in merge-sweep-cov.sh right after the `failed`/`DIRTY` checks:
  `if [ "$rev" != "APPROVED" ]; then echo "SKIP #$n review=$rev"; done_pr=1; break; fi`
· reference: CLAUDE.md rule 10 (DRY/consistency at the right level); tools/CONVENTIONS.md ("one clear
  purpose per script" — the two sweep scripts are meant to behave identically apart from the
  Sonar-coverage carve-out)

Subagents

Agent Verdict
parity-checker N/A — no apps/web/** or apps/mobile/** file changed
i18n-syncer N/A — no user-facing strings or i18n JSON changed
contract-aligner N/A — no packages/shared/src/types/* / endpoints.ts, and only one repo changed
security-reviewer N/A — no orbit-api code changed
design-reviewer N/A — no apps/web, apps/mobile, or orbit-landing-page/src UI file changed

Validation

Check Result
Lint N/A — CI wrapper skips /validate; Build/Unit Tests/SonarCloud run as separate required checks
Type check N/A — same
Tests N/A — same
Build (api) N/A — orbit-api not touched

Manually verified: bash -n on both scripts (syntax), field-order alignment between each mstate/gate
JS emitter and its corresponding bash read, and that the --paginate/fail-closed workflow lookup and
SHA-based (not existence-based) orphan check the PR body claims are actually present in the diff as
pushed.

Deferred — N/A dimensions & files not verdicted

  • Parity / i18n / contract-drift / backend-security / backend-hard-rules / FEATURES.md parity: all N/A,
    gated out because the diff touches only .claude/ and tools/ (no apps/*, packages/shared,
    orbit-api, or user-facing feature surface).
  • Comment-policy dimension (#4): scoped by the rubric to no-comments.cjs (TS/JS) and ORBIT0001 (C#);
    bash isn't gated by either, so the new inline race-condition comments in the two scripts were read for
    quality but not held to the JSDoc/WHY-URL lint bar.
  • .claude/pending-lessons.md: the newly Graduated "2026-07-24 background subagents idle on phantom
    background waiters" entry never appears as a ## Pending queue row anywhere in this file's history —
    only the "2026-07-14 sweep-merge" entry was actually staged there before this PR. This is a bookkeeping
    observation on a prose changelog, not a code defect, so it is not raised as a Low finding per the
    Signal gate; noted here for the author's awareness.
  • All six changed files (.claude/pending-lessons.md, .claude/skills/orchestrate/SKILL.md,
    tools/README.md, tools/dash-baseline.json, tools/merge-sweep-cov.sh, tools/merge-sweep.sh) were
    read and verdicted; nothing left uncovered.

What's good

  • The core race fix is correct and precisely targeted: the guard re-fetches reviewDecision and the
    review check's settle state together in one gh pr view call every poll, so a flip to
    CHANGES_REQUESTED is caught in the same read that observes the check going terminal — no window for
    a stale APPROVED to slip through, including on the coverage-admin override path.
    (tools/merge-sweep-cov.sh:133-139, tools/merge-sweep.sh:98-106)
  • Fail-closed default on the workflow lookup (review_required=1 unless a successful lookup positively
    shows no claude-review.yml) correctly means an auth/rate-limit hiccup makes the sweep slower, never
    less safe. (tools/merge-sweep.sh:55-62, tools/merge-sweep-cov.sh:62-69)
  • The orphan-head scan is SHA-based, not existence-based, so a branch that merely outlives
    --delete-branch's eventual-consistency lag is correctly not reported — only a tip that actually moved
    past the merged commit is. (tools/merge-sweep.sh:125-139, tools/merge-sweep-cov.sh:166-180)
  • New --help/usage text, documented exit codes, and the tools/README.md catalog rows are accurate to
    the new behavior and match tools/CONVENTIONS.md's contract.
  • The PR body's own reproduction table (stub-gh cases 1-7) is a genuinely good piece of evidence for a
    concurrency fix that's otherwise hard to test.

Recommendation

Approve as-is. The one Medium finding (merge-sweep.sh's missing fast-path SKIP on a non-approved PR) is a
minor efficiency/consistency gap, not a safety issue — the script still correctly refuses to merge, it
just takes longer to say so. Fine to land now and pick up in a follow-up if the author wants the two
scripts to match exactly.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #597

Recommendation: APPROVE

Summary

Promotes both queued entries in .claude/pending-lessons.md: a checkable lesson that becomes a fail-closed merge guard in tools/merge-sweep.sh / tools/merge-sweep-cov.sh (block every merge path until the review check on the current head SHA settles, then re-read reviewDecision, plus an end-of-sweep scan for orphaned head branches), and a judgment lesson landed as prose in .claude/skills/orchestrate/SKILL.md. Pure tooling + docs diff, no apps/*, orbit-api, or packages/shared surface touched. The guard logic was traced step by step against every branch (APPROVED/CHANGES_REQUESTED, BEHIND, DIRTY, Sonar FAILURE, workflow-lookup failure, orphaned head) and is sound; one Medium defense-in-depth gap survived in the new orphan-scan error handling.

Findings

Critical

None.

High

None.

Medium

Orphan-scan swallows non-404 gh api failures as "no orphan", the same fail-open pattern this PR eliminates elsewhere

  • location: tools/merge-sweep.sh:134, tools/merge-sweep-cov.sh:175
  • issue: tip=$(gh api "repos/$repo/branches/$branch" --jq .commit.sha 2>/dev/null) || continue treats every non-zero gh api exit identically. A confirmed 404 (branch genuinely deleted by --delete-branch, the expected/benign case) and a transient failure (rate limit, network blip, auth hiccup) both silently continue with no diagnostic, so the run reports "clean" (exit 0, no ORPHANED-HEAD line) whether or not that PR's branch was actually verified.
  • risk: this is the identical fail-open shape the PR's own "Why the precondition check itself fails closed" section calls out and fixes for the workflow-lookup a few lines above in the same diff — the fix wasn't carried to this second lookup. If the orphan check silently no-ops during a transient error, a real orphaned head (the exact HIGH-severity failure mode from orbit-api#403 that this PR exists to catch) goes unreported and is never re-checked later, since merged_heads is per-invocation, in-memory state.
  • fix: distinguish "confirmed deleted" from "could not verify" (e.g. check for a literal 404 via --include), and only silently skip on a confirmed 404. On any other failure, print a WARN: could not verify branch <branch> for #<pr>; orphan status unknown to stderr, mirroring the existing WARN: could not list ... workflows pattern used one lookup above.
  • reference: CLAUDE.md rule 1 (root-cause consistency within the same diff); rubric dimension 3 (defense-in-depth gap, Medium).

Low / Info

None posted (signal gate).

Subagents

All gated N/A — the diff touches no apps/web/**, apps/mobile/**, orbit-api, packages/shared/src/types/*, or i18n JSON. Frontend-security categories reviewed inline instead: no injection surface (repo/pr only ever passed as gh argv, never eval'd); the two check/workflow-name interpolations into the inline node -e scripts are hardcoded constants, not user input.

Validation

Lint / type check / tests / build: N/A — diff is .sh + .md + .json only, no ESLint/Roslyn/tsc-covered surface changed, and this repo has no CI gate for shell syntax. Dash-baseline removal independently verified (git show <ref>:<file> | grep -c '—' = 0 for every changed prose file). Shell syntax verified by manual trace of every case/esac, if/fi, for/done, and function brace, rather than an automated bash -n run (blocked in this review session's sandbox).

Deferred

Dimensions 8/9/10/11/13/14 (DESIGN.md, parity, i18n, contract drift, backend hard rules, FEATURES.md) — N/A by gate, no matching surface in the diff. Phase 5 backward-compat guard — N/A, no shared-type/DTO hunks to classify. All 6 changed files received a verdict.

What's good

The review-staleness guard is genuinely correct end to end (traced through approved-settle, changes-requested-settle, workflow-absent-repo, and workflow-lookup-failure branches). The workflow-lookup fail-closed fix (--paginate, inverted default, WARN on failure) is exactly right. Both scripts gained --help and documented exit codes per tools/CONVENTIONS.md. The pr^branch^sha orphan-tracking encoding is small and correctly parsed.

Recommendation

Approve as-is. The one Medium finding is a real but narrow defense-in-depth gap in a post-merge, best-effort detection pass — worth a fast follow-up, not a blocker.

Addresses the Medium finding on the approving review of #597: the orphan scan's
`gh api repos/<repo>/branches/<b> || continue` treated a confirmed 404 (the
expected case, the branch really was deleted) and a transient failure (rate
limit, network, auth) identically, so a run could report exit 0 with no
ORPHANED-HEAD line without having verified anything. That is the same fail-open
shape this PR removes from the workflow lookup twenty lines above.

Both scripts now resolve the ref through GraphQL, which exits 0 with an EMPTY oid
for a deleted branch. A non-zero exit is therefore unambiguously "could not
verify": it warns on stderr and exits 3, a distinct code from 1 (a real orphaned
head). Silence now means verified, per the locked "a verifier error is not a
clean pass" decision.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #597

Recommendation: APPROVE

Summary

Tooling/process PR: promotes both .claude/pending-lessons.md entries (one as a gate, one as a rule), hardens tools/merge-sweep.sh and tools/merge-sweep-cov.sh against the orbit-api #403 race (update-branch re-triggers the review check while the PR-level reviewDecision still reports the stale pre-update value), adds an end-of-sweep orphaned-head-branch scan proven by SHA rather than existence, and lands "waiting is foreground work" guidance in .claude/skills/orchestrate/SKILL.md. No apps/*, packages/shared, or orbit-api files touched. I traced both scripts' control flow line-by-line against the PR's embedded 7-case stub-harness proof and independently re-derived the same behavior; the review-staleness guard correctly gates before the coverage-only admin-override path too, not just the plain-merge path — the one place a bypass could have slipped back in.

Findings

Critical: None. High: None. Medium: None. Low/Info: none posted per the rubric's signal gate.

Subagents (all N/A — diff touches only .claude/ and tools/)

parity-checker N/A · i18n-syncer N/A · contract-aligner N/A · security-reviewer N/A · design-reviewer N/A

Validation

No lintable/testable app surface changed. bash -n, check-frontmatter.mjs, and check-dashes.mjs --check-baseline were not re-executed in this sandboxed session (execution required an approval this environment could not grant); the PR body's own verification transcript reports all green, and I independently confirmed via gh pr diff that no added line introduces an em/en dash, matching the dash-baseline.json shrink.

Deferred

Dimensions 8/9/10/11/13/14 (DESIGN.md, parity, i18n, contract drift, backend hard rules, FEATURES.md) are N/A — the diff never touches their gated surface. Lint/type-check/test execution not independently re-run this session; PR's own transcript is the evidence of record. All 6 changed files received a verdict; nothing skipped for size.

What's good

  • Review-staleness guard is ordered before the Sonar coverage-only --admin override in merge-sweep-cov.sh, so the one branch-protection-bypassing path isn't a back door around the new guard.
  • Fail-closed by construction: a failed/unpaginated workflow lookup now defaults to "review required" instead of silently disabling the guard (closes the hole the first draft shipped with, per the PR's own case 4b).
  • Orphan scan proves drift by comparing the recorded merged SHA against the branch's current tip via GraphQL (empty oid = confirmed-deleted, non-zero exit = confirmed-unknown), not branch existence alone.
  • --help, documented exit codes (0/1/2/3), cwd-independent and non-interactive per tools/CONVENTIONS.md.
  • Watch-item, not a finding: merge-sweep.sh/merge-sweep-cov.sh now duplicate a fair amount of new logic (workflow lookup, branch_tip(), orphan-scan loop) verbatim. Per CLAUDE.md rule 6 ("extract on the third real use"), two scripts don't clear the bar yet for a shared helper — worth revisiting if a third sweep script appears.

Recommendation

Merge as-is.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #597

Scope: PR #597 in thomasluizon/orbit-ui-mobile
Recommendation: APPROVE

Summary

This PR promotes both queued .claude/pending-lessons.md entries. The checkable lesson becomes a
fail-closed review-staleness + orphaned-head guard added to tools/merge-sweep.sh and
tools/merge-sweep-cov.sh; the judgment lesson becomes standing guidance in
.claude/skills/orchestrate/SKILL.md. The diff is entirely tooling/docs (no apps/*,
packages/shared, or orbit-api files), so the design, parity, i18n, contract-drift, security,
backend-hard-rules, and FEATURES.md dimensions are all out of surface. The two shell scripts
(safety-critical, since they auto-merge PRs to main) got the closest read; the added guard logic
is sound and does fix the documented #403 race, with one concrete edge-case gap below.

Findings

Critical

None.

High

None.

Medium

[MEDIUM] Branch-name lookup for the orphan-scan can fail silently and skip verification without counting it as unverified
· dimension: 1. Correctness / 5. No-workaround (error handling, CLAUDE.md rule 8)
· location: orbit-ui-mobile/tools/merge-sweep.sh:109 and orbit-ui-mobile/tools/merge-sweep-cov.sh:97 (squash_merge)
· issue: Right before merging, both scripts fetch the branch name with a second, independent gh pr view … --json headRefName --jq .headRefName 2>/dev/null call. If that call fails (network blip, secondary rate limit) it silently yields an empty string; the merge still proceeds and merged_heads gets an entry with an empty branch. In the orphan-scan loop, [ -n "$branch" ] || continue then skips that entry entirely — it is not added to unverified and never printed as a WARN, so it neither shows up in the report nor trips exit code 3.
· risk: This is the exact failure mode both scripts' own header comments and exit-code contract explicitly promise never happens ("unknown is not a clean pass", exit 3 documented for "a head branch could not be verified"). A transient API hiccup at merge time silently drops that one PR out of the orphan check, and the sweep still reports SWEEP-DONE / exit 0 as if every merged head had been verified clean.
· fix: Don't make a second network call at all — add headRefName to the existing gate/mstate gh pr view --json ... call that already runs every poll iteration (it already succeeded, since that's the data driving the merge decision), and use that value directly in squash_merge/the merge branch. That removes the extra failure surface entirely. If a separate call is kept, at minimum treat an empty branch the same as a failed branch_tip lookup: warn on stderr and increment unverified instead of continue.
· reference: CLAUDE.md rule 8 (error handling — never swallow errors silently); tools/CONVENTIONS.md "Gate tools" (a gate's verdict must be computed from artifacts on disk, never silently assumed clean).

Low / Info

None.

Subagents

Agent Verdict
parity-checker N/A — no apps/web/** or apps/mobile/** file changed
i18n-syncer N/A — no user-facing strings or i18n JSON changed
contract-aligner N/A — no packages/shared/src/types/*/endpoints.ts or orbit-api DTOs changed
security-reviewer N/A — no orbit-api code changed
design-reviewer N/A — no apps/* or orbit-landing-page UI files changed

Validation

Check Result
Lint N/A — this review session's sandbox blocks node/npm execution; diff has no TS/JS/C# files to lint
Type check N/A — same reason; no typed source changed
Tests N/A — same reason; the PR's own proof transcript (stub-gh harness, scratchpad-only) is in the PR body
Build (api) N/A — orbit-api not touched

Deferred — N/A dimensions & files not verdicted

  • 8. DESIGN.md / AI-slop — N/A, no apps/* UI files touched.
  • 9. Parity — N/A, no apps/web/**/apps/mobile/** files touched.
  • 10. i18n — N/A, no user-facing strings or locale JSON touched.
  • 11. Contract drift + backward-compat — N/A, no packages/shared/src/types/* or orbit-api DTOs touched.
  • 12. Security — N/A, no orbit-api, no apps/* trust-boundary code touched; the shell scripts run only against gh/GitHub's API with no new secret handling.
  • 13. Backend hard rules — N/A, orbit-api not touched.
  • 14. FEATURES.md parity — N/A, purely internal tooling/process change, no user-facing feature surface.
  • Phase 7 Validate — not executable in this review sandbox (Bash calls to node/npm require an approval this session does not have); the PR body's own verification transcript (check-frontmatter.mjs, test-hooks.mjs, check-dashes.mjs --check-baseline, bash -n on both scripts) is the available evidence and was not independently re-run here.
  • Every changed file (tools/merge-sweep.sh, tools/merge-sweep-cov.sh, tools/README.md,
    tools/dash-baseline.json, .claude/pending-lessons.md, .claude/skills/orchestrate/SKILL.md)
    received a verdict above; nothing else deferred.

What's good

  • The core fix is correct and well-targeted: gating every merge path on the review check for the
    current head SHA reaching a terminal state before re-reading reviewDecision genuinely closes
    the #403 race (a stale PR-level reviewDecision surviving a re-triggered check).
  • The workflow-lookup precondition is properly fail-closed now: --paginate fixes the page-30 cap
    bug, and a failed lookup defaults review_required=1 (warns on stderr) rather than silently
    disabling the guard — this is the right shape for a gate whose own precondition check can fail.
  • The orphaned-head scan's "moved past" comparison (vs. "branch merely survived deletion") correctly
    avoids the false-positive the PR's own changelog says a prior draft had, and separates "confirmed
    orphaned" (exit 1) from "could not verify" (exit 3) instead of collapsing unknown into clean.
  • --help, explicit usage, and documented exit codes bring both scripts in line with
    tools/CONVENTIONS.md.
  • The judgment-lesson landing in orchestrate/SKILL.md's existing "Delegation discipline" section
    (rather than the always-loaded .claude/rules/core.md) matches the stated rationale: it's read
    only when delegating, not paid for on every turn.

Recommendation

Approve as-is. The one Medium finding is a narrow, well-scoped follow-up (fold headRefName into
the already-polled JSON instead of a second lookup) — worth fixing soon since it's cheap and removes
a real gap in the exact invariant this PR exists to establish, but it does not block merge.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #597

Scope: PR #597 in thomasluizon/orbit-ui-mobile (chore/promote-pending-lessons → main), head bdb63a7a
Recommendation: APPROVE

Summary

Pure tooling/docs diff (.claude/, tools/ only — no apps/*, packages/shared, or orbit-api). It graduates both queued .claude/pending-lessons.md entries: a fail-closed review-staleness + orphaned-head guard added to tools/merge-sweep.sh / tools/merge-sweep-cov.sh (targeting the orbit-api #403 race where reviewDecision survives an update-branch push while the re-triggered review check on the new head SHA is still running), plus prose guidance landed in .claude/skills/orchestrate/SKILL.md. This PR has already been through 7 review passes; five prior findings were fixed in later commits and are confirmed fixed in the current head (paginated + fail-closed workflow lookup, SHA-based rather than existence-based orphan detection, GraphQL-based orphan lookup that distinguishes confirmed-deleted from unknown). Two Medium findings from earlier passes were never addressed and are confirmed still present — re-raised below per the instruction to flag unresolved (not already-addressed) concerns.

Notably: at the time of this review, the PR's own reviewDecision field reads APPROVED while the review check for the current head SHA (this very review) is still IN_PROGRESS — a live instance of exactly the race this PR patches.

Findings

Critical: None.
High: None.

Medium:

  1. tools/merge-sweep.sh:83-122 — no fast-path SKIP when reviewDecision != APPROVED, unlike merge-sweep-cov.sh:113-117. A rejected PR just sleeps/re-polls for up to ~17 minutes before a generic timeout message instead of an immediate SKIP #$n review=$rev. Fix: add the same early check merge-sweep-cov.sh has, right after the failed/DIRTY checks.
  2. tools/merge-sweep.sh:109 (and tools/merge-sweep-cov.sh:97 in squash_merge) — the second, independent gh pr view --json headRefName call made right before merging can fail transiently, silently leaving branch empty (swallowed by 2>/dev/null, no exit-status check). The merge still proceeds, and the orphan-scan's [ -n "$branch" ] || continue drops that entry without incrementing unverified or printing a WARN — the run can report exit 0 as if every merged head were verified clean, the exact fail-open shape this PR closes for the adjacent branch_tip() lookup a few lines below. Fix: source the branch name from the existing gate/mstate payload (already fetched every poll) instead of a second call; if a second call is kept, treat an empty result the same as a failed branch_tip lookup (WARN + increment unverified).

Both were raised in earlier review passes on this same PR and remain unaddressed in the current head; both are Medium (consistent with how this review history classified the closely analogous, already-fixed branch_tip() error-swallowing issue), so per the rubric's signal gate neither forces NEEDS WORK.

Subagents

All N/A — diff touches only .claude/ and tools/ (no apps/web, apps/mobile, orbit-api, packages/shared/src/types, or i18n JSON).

Validation

Phase 6 (/validate) skipped per instructions — CI runs Build/Unit Tests/SonarCloud separately as required checks. As of this review: Lint, Type Check, Build, Dash Ban, Copy Register, Suppressions Ratchet, Cross-Platform Parity, Contract Drift, CodeQL all SUCCESS; Unit Tests and SonarCloud Analysis were still IN_PROGRESS on the current head at review time (a prior SonarCloud run on an earlier SHA had passed cleanly, since superseded by the last merge-from-main commits). No orbit-api dimension to mark "not verifiable in CI" — the diff never touches that repo.

Deferred

Dimensions covering DESIGN.md, parity, i18n, contract drift, backend security, backend hard rules, FEATURES.md — N/A, no matching surface in the diff. All 6 changed files (.claude/pending-lessons.md, .claude/skills/orchestrate/SKILL.md, tools/README.md, tools/dash-baseline.json, tools/merge-sweep-cov.sh, tools/merge-sweep.sh) received a verdict.

What's good

  • The core review-staleness fix is correct and applied to both sibling scripts: re-reads reviewDecision alongside the review check's settle state on the current head SHA every poll, gated before both the plain-merge and the Sonar coverage-only --admin override path.
  • Fail-closed workflow-lookup (--paginate, review_required=1 default, WARN on failure) correctly closes the earlier High finding (unpaginated + error-swallowed lookup).
  • Orphan-scan is SHA-based (not existence-based) via a GraphQL ref lookup that distinguishes a confirmed-deleted branch (empty oid) from an unverifiable one (non-zero exit → WARN + exit 3), correctly closing two earlier Medium findings.
  • --help, documented exit codes, and tools/README.md catalog rows match tools/CONVENTIONS.md's contract; zero em/en dashes remain in either script, matching the dash-baseline.json shrink.

Recommendation

Approve as-is. The two Medium findings are real but narrow (wasted polling time; a rare transient-failure gap in a post-merge, best-effort audit), not safety-blocking — worth a fast follow-up rather than another review cycle on this already seven-times-reviewed PR.

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 7baba49 into main Jul 24, 2026
27 checks passed
@thomasluizon
thomasluizon deleted the chore/promote-pending-lessons branch July 24, 2026 21:22
thomasluizon added a commit that referenced this pull request Sep 19, 2026
The same hole, wider. subscribeToPushNotifications waits on the browser's
permission prompt, which the person can sit on for as long as they like, and
the shared auth cookie can change under them the whole time. Registering this
browser's endpoint under whichever account signed in meanwhile would send that
account's push notifications to a device they never armed.

Both push writes now read the account when the intent forms, before the prompt,
and carry it to the same server-side check the four inbox writes use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Sep 19, 2026
The signal carried a sign in and nothing else, so a tab whose sibling signed
the browser out kept the dead account's habits, goals and alerts on screen for
whoever was at the keyboard next, for up to the full 60 seconds. That is the
same window, unshrunk for half the transition.

The payload now carries a null account for a sign out, and the receiving tab
ends its session exactly the way the tab that pressed the button does. Both
callers share endSessionLocally, so the two cannot drift. An unrecognised
payload is still dropped rather than read as a sign out, because any tab on
this origin can post on the channel and a stray message must not tear down a
live session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Sep 19, 2026
The signal was dropping. A channel opened per message and closed in the same
turn delivered 0 of 200 messages here on 2026-09-18, where a channel left open
delivered 200 of 200. Closing tears the port down before the runtime moves
anything across it, so an announcement made that way reached nobody and the
60 second poll was still doing all the work.

One channel now serves the tab for its life, opened on first use. A channel
never receives its own posts, so this also removes the self delivery the two
channel shape had, with no tab id to filter on. The listener teardown drops
the listener and leaves the channel, because the tab keeps announcing its own
transitions after the shell that was listening unmounts.

The three test helpers that stood in for a second tab carried the same defect
and now hold their channel open too. The signal suite ran eight times without
a failure after the change, where it failed one run in four before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Sep 19, 2026
Channel delivery runs on the event loop, not on a timer, so awaiting one
macrotask proved nothing. Under the full suite's load the delivery landed
after that turn and the first assertion in the signal file went red in one run
of four, on working code.

Every test now waits for the signal to actually arrive. A test that expects
nothing posts a sentinel afterwards and waits for that instead: one channel
delivers in order, so a sentinel that arrived with nothing before it proves
the earlier message was dropped on purpose rather than merely late. The store
and hook tests record on the same channel the store listens on, and both
listeners run in one dispatch, so a signal the recorder has seen the store has
seen too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Sep 19, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Sep 19, 2026
* Fix notification mutation failure feedback

* Prevent stale notification delete errors

* Clear delayed deletes at session boundaries

* Fix root shell test type comparison

* Guard notification rollback by session

* Guard notification delete execution by session

* Give the mobile test runtime the Expo globals

The Expo runtime installs a global `expo` object through JSI and registers each
native module on it before any JavaScript runs. Node gives Vitest no such
runtime, so `expo-modules-core` read `globalThis.expo.EventEmitter` on an
undefined global and every suite that reached an Expo module failed at import.
`notification-inbox-ownership.test.tsx` was the first suite to reach SecureStore
without a per-file mock, so it took CI red with zero of its own tests run.

Install the global through `installExpoGlobalPolyfill`, the entry point Expo
publishes for a test runtime, then register the `ExpoSecureStore` and
`ExpoApplication` native modules from their real module definitions. Route the
`expo` package root and `expo-sqlite` through shared doubles: the root entry
requires a TypeScript file only Metro can rewrite, and SQLite is a native engine
the test runtime does not reproduce.

Two per-file `expo-secure-store` mocks existed only to get past the missing
global and asserted nothing, so they go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Gate every notification mutation by its owning session

Rounds 3 and 4 bound only `useDeleteNotification` to the session that started
it. The other three mutations kept writing without a session check: on both
platforms `onError` restored the snapshot it took in `onMutate` and `onSettled`
invalidated the list. TanStack Query runs those callbacks from the options
snapshot the mutation captured, and `queryClient.clear()` empties the caches
without cancelling a retryer, so a request account A started could reject after
A signed out and B signed in. A's items then landed in B's cache and A's failure
text was announced to B.

Capture the session in `onMutate`, carry it in the mutation context, and gate
the optimistic write, the rollback, the announcement and the invalidation for
all four mutations on both platforms.

Close the hole that defeated the gate itself. The web store read only
`expiresAt`, so a tab could not see the shared cookie move to another account
and kept the replaced account's generation. `/api/auth/session` now reports
`userId`, decoded from the token the route already holds, and a changed account
raises the generation, clears the pending notification deletes, rebinds step-up
and drops the remembered user. A tab learning its account for the first time
only records it, so a same-account reload is untouched. Both login paths clear
the pending deletes as well.

Move the duplicated guard to `packages/shared` as `createSessionScopedRunner`
and give both stores one `getSessionEpoch(): number`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Empty the query cache when the cookie changes account

`adoptSessionAccount` raised the session generation, cleared the pending
notification deletes, rebound step-up and dropped the remembered user, but left
the query cache untouched. Raising the generation stops the previous account's
writes; nothing evicted its reads. A tab that switches account never navigates,
so nothing else would either. With `staleTime` at 60 seconds and the
notification poll at 5 minutes, tab one sat on account B while rendering account
A's notification titles and bodies, habits, goals and profile.

Clear the cache there, the way mobile already clears it at both account
boundaries. Web logout needs no such call because it sets
`globalThis.location.href` and the navigation discards the client outright.

Also delete the `getSessionGeneration` export. Its only consumer moved to
`getSessionEpoch`, which left two exported names for one value and no callers
for one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Route every account change through one session path

Web kept three writers for the account a tab holds and only one of them
emptied the query cache, so a dead session followed by a login showed the
previous account's notifications, habits, goals and profile. The three now
route through one path that raises the generation, drops the pending deletes
and empties the cache, and the last observed account outlives a teardown so
the session after it can tell a return from a replacement.

Every notification mutation now carries the session that started it into its
request, so a clear-all that resumes after an account replacement no longer
destroys the replacement account's notifications.

A failed delayed delete now keeps its notice for a bounded life instead of
stacking one more line onto every authenticated route for as long as the tab
runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Empty the Astra conversation when the account changes

Route the chat store through the one path that changes which account a web tab
holds, so a replacement account cannot read the previous account's conversation
out of the module store while the shell stays mounted. Mobile already cleared it
at both of its session boundaries, so this closes a parity break.

Scope that path to a real account change: a teardown names no account, so it no
longer counts as one, and a session that drops and returns as the same account
keeps its cache instead of blanking the tab.

Drop the ownership re-check after the cancel await in both notification hooks.
Every caller wraps the work that follows in the session-scoped runner, which
reads the current epoch at the point of each write, so the earlier check decided
nothing and no test could redden on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Empty the Astra composer when the account changes

Zustand merges a partial set, so clearMessages left draft, draftRevision,
draftHydrated and contextualSuggestion behind on both platforms. The composer
rehydrates from storage whenever draftHydrated is false, so the stored draft
goes with the state reset: localStorage on web, AsyncStorage on mobile.

Also make startAccountScopedSession return void, since all three call sites
discard the flag, and tidy two test helpers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Follow the session from the state a store cannot reach

An account change that crosses a page load could not be recognised, so a sign
out left the Astra draft in device storage for the next person, and the never
unmounted composer kept the previous account's attempted send armed behind
Retry.

Every account transition now raises one session epoch and resets the Astra
chat, a sign out included. The query cache alone stays gated on a real account
change, because blanking a tab that the same account recovers costs that
account its rows. React state that no store holds subscribes to the epoch, so
the attempted send, the image and the text file go with the session.

Mobile awaits the draft removal before the state reset, because clearing
draftHydrated sends the composer straight back to storage and the Android
storage module orders nothing; a rejected removal is reported rather than
dropped. The mobile hydration effect now depends on draftHydrated, so a draft
saves again after a sign in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep the Astra draft through a session that recovers

The chat reset ran on every transition through startAccountScopedSession,
so a rejected refresh that recovered as the same account threw away a
half written Astra message. Round 4 settled the opposite for the query
cache: a same account recovery must not blank the tab, and the composer
is part of that tab.

Gate the chat reset on a session STARTING under an account, and call it
from logout. A teardown names no account, so a recoverable wobble now
keeps the draft. A sign in still clears it, which is what closes the path
a page load erases: lastObservedAccountId dies with the document, so a
sign out followed by somebody else signing in reaches setAuth with no
previous account to compare against.

Mobile needs no change. It resets on every login and every teardown
already, and it has no recoverable wobble: a transient refresh failure
leaves the session mounted and tears nothing down. Two mobile assertions
now hold that claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Forget the support draft and follow the account, not the session

Both drafts a person types live under one key with no account in it. The
Astra one is forgotten at every account transition; the support one was
not, so the next person to sign in on the same browser or device read the
previous person's subject and message back into the form and could send
them under their own name.

One module per platform now owns the support key, and the auth store
forgets both drafts through a single path rather than at two call sites.

The composer state no store can reach followed the session epoch, which
rises on every credential change. A rejected refresh keeps the shell
mounted behind the expiry banner, so a wobble that recovered as the same
account revoked the pasted image and disarmed the retry while the person
was still looking at them. An account generation now rises only where a
draft is forgotten, and the composer hooks subscribe to that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop the failed delete clock while somebody is reading it

Thomas settled that the failed delete toast keeps its ten seconds and
pauses while a pointer rests on it or focus sits inside it, the same way
the success toast already behaves. It gains no dismiss button and its one
action stays Retry.

The life ran on a bare timer inside a shared module that cannot see either,
so the retry left the screen while the person was reaching for it. A
neutral toast may now carry a life, and the notice hands it the ten
seconds and the dismissal instead of running its own clock.

The life is optional, so the other eight neutral callers are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record the surfaces this branch moved

The session identity, the account reset and the support draft storage sit
in the import closure of every route and block that reaches the auth
store, so 166 surface records no longer described the tree and the
required drift check was red.

None of the movement is visual: 716 closure sizes and one owned-file list
change, with no surface added, removed or re-routed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let the toast contract carry the life it was granted

The type test still pinned neutral to no life at all, which is the rule
Thomas replaced. It now asserts the opposite: a neutral toast may carry
both the life and its end, and every other variant keeps its bans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Take the previous account words off the screen, not only out of storage

The support draft, the open notification and the revealed API key were all
component state, so the account change that cleared their storage and their
query cache left them rendered for whoever signs in next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retire one delete from pending before it is reported as failed

The finally block emitted after the catch, so a synchronous throw published one
snapshot holding the same id as both pending and failed. Cover the account claim
the web session route reads with a token built from the payload the API issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record the five closures the account reset moved

Read the surface manifest back after regenerating it: the committed inventory
matches the tree at 186 surfaces and 800 cells, and none of the movement is
visual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover the session counter where it lives

The counter is shared code exercised only from the two apps, so its four
functions never ran under the shared workspace coverage run and the function
total tipped under its threshold at 95.98 against 96.

The listener copy in advance gets the case that proves it: a callback that
unsubscribes a sibling mid pass still lets that sibling run. Self
unsubscription passes without the copy, because a Set skips only entries it
has not visited yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Send the next account through onboarding on its own merits

The retained onboarding guard froze whether the account had habits, and spent its one
auto-complete, in state that outlives an account change because neither shell unmounts. A
brand-new second account inherited the first account's answer and was auto-completed instead of
onboarded. Both the snapshot and the spent attempt now carry the account generation they belong to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close the cross-tab account window on notifications (#597)

A tab learned that the shared auth cookie holds a different account only on
its 60-second session poll. Inside that window a stale callback wrote the
previous account's data into the cache, and, worse, an outbound notification
write left under the next account's cookie, so DELETE /notifications emptied
an inbox nobody asked about.

Two mechanisms, because the two directions need different ones.

Inbound: a BroadcastChannel signal announces the account a session starts
under, and the receiving tab runs the same adoptSessionAccount path a detected
change already runs. The 60-second poll stays as the fallback wherever the
signal cannot arrive.

Outbound: every notification write carries the account its intent was formed
under, and serverAuthFetch refuses the request when the cookie it is about to
send names somebody else. A client counter cannot decide this, because the
counter says what the tab believes and the cookie says what the server acts on.

Mobile gets no equivalent. One app has one runtime and no second tab, and each
request reads the token from SecureStore and sends it as an explicit Bearer,
so the credential is bound when the request is built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: regenerate the surface inventory for the cross-tab signal module (#597)

The new web module joins every web surface's import closure, so the committed
closure sizes no longer described the tree and the surface-manifest gate went
red. The surface list itself is unchanged: 186 surfaces, 800 cells.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Gate the push subscription writes by account too (#597)

The same hole, wider. subscribeToPushNotifications waits on the browser's
permission prompt, which the person can sit on for as long as they like, and
the shared auth cookie can change under them the whole time. Registering this
browser's endpoint under whichever account signed in meanwhile would send that
account's push notifications to a device they never armed.

Both push writes now read the account when the intent forms, before the prompt,
and carry it to the same server-side check the four inbox writes use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Signal a sign out across tabs as well (#597)

The signal carried a sign in and nothing else, so a tab whose sibling signed
the browser out kept the dead account's habits, goals and alerts on screen for
whoever was at the keyboard next, for up to the full 60 seconds. That is the
same window, unshrunk for half the transition.

The payload now carries a null account for a sign out, and the receiving tab
ends its session exactly the way the tab that pressed the button does. Both
callers share endSessionLocally, so the two cannot drift. An unrecognised
payload is still dropped rather than read as a sign out, because any tab on
this origin can post on the channel and a stray message must not tear down a
live session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Hold the account channel open for the life of the tab (#597)

The signal was dropping. A channel opened per message and closed in the same
turn delivered 0 of 200 messages here on 2026-09-18, where a channel left open
delivered 200 of 200. Closing tears the port down before the runtime moves
anything across it, so an announcement made that way reached nobody and the
60 second poll was still doing all the work.

One channel now serves the tab for its life, opened on first use. A channel
never receives its own posts, so this also removes the self delivery the two
channel shape had, with no tab id to filter on. The listener teardown drops
the listener and leaves the channel, because the tab keeps announcing its own
transitions after the shell that was listening unmounts.

The three test helpers that stood in for a second tab carried the same defect
and now hold their channel open too. The signal suite ran eight times without
a failure after the change, where it failed one run in four before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Wait for the signal to arrive rather than for a timer (#597)

Channel delivery runs on the event loop, not on a timer, so awaiting one
macrotask proved nothing. Under the full suite's load the delivery landed
after that turn and the first assertion in the signal file went red in one run
of four, on working code.

Every test now waits for the signal to actually arrive. A test that expects
nothing posts a sentinel afterwards and waits for that instead: one channel
delivers in order, so a sentinel that arrived with nothing before it proves
the earlier message was dropped on purpose rather than merely late. The store
and hook tests record on the same channel the store listens on, and both
listeners run in one dispatch, so a signal the recorder has seen the store has
seen too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: regenerate the surface inventory at the merged head (#597)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant